iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
Build on Google AI

給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手系列 第 9 篇

Day 09|跨科開藥不怕吃錯!用 Gemini VLM 實作重複藥性與高風險警語檢測

  • 分享至 

  • xImage
  •  

✏️【本日實作紀錄:用藥安全過濾器與重複藥性檢測】

今天進入用藥安全檢測與風險預警階段,完成項目包含:

  • 擴充 JSON Schema 結構 — 在 Gemini 輸出規範中新增 safety_warnings 欄位,讓 VLM 直接檢測重複藥性與高風險用藥指示。
  • 實作後端安全過濾機制 — 當系統偵測到高風險警語時,將自動在白話文摘要(spoken_summary)開頭加上警示,提醒長輩與家屬特別留意。
  • 升級 LINE 關懷推播內容 — 推播訊息同步包含「⚠️ 安全用藥提醒」卡片區塊,確保家屬能第一時間接收到潛在的重複用藥風險通知。

一、 為什麼需要重複藥性與高風險檢測?

高齡長輩常因跨科別就醫,容易出現跨院重複開藥或劑量過量的問題。例如同時看骨科與內科,可能重複開立 NSAID 消炎止痛藥或抗組織胺。

在後端導入 AI 風險過濾器有好處。傳統藥盒只能提示吃藥時間,但無法判斷藥物成分是否衝突。透過 VLM 語義分析,可以在長輩服藥前發出警告。當系統發現潛在重複用藥風險時,同步透過 LINE 推播通知家屬,由家屬協助向藥師或醫師確認。

二、 編寫安全過濾器與 API 整合邏輯(app.py)

開啟專案根目錄下的 app.py,更新為以下完整程式碼:

import os
import json
import uuid
import sqlite3
from flask import Flask, request, jsonify, send_from_directory
from dotenv import load_dotenv
from google import genai
from google.genai import types
from PIL import Image
from gtts import gTTS

# LINE Bot SDK 引入
from linebot.v3.messaging import (
    Configuration,
    ApiClient,
    MessagingApi,
    PushMessageRequest,
    TextMessage
)

# 1. 載入環境變數與初始化
load_dotenv()
api_key = os.getenv("GEMINI_API_KEY")
line_access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN")
line_user_id = os.getenv("LINE_USER_ID")  # 家屬的 LINE User ID

if not api_key:
    raise ValueError("❌ 錯誤:找不到 GEMINI_API_KEY,請檢查 .env 設定!")

client = genai.Client(api_key=api_key)

# 初始化 LINE Messaging API Client
if line_access_token:
    configuration = Configuration(access_token=line_access_token)
    line_api_client = ApiClient(configuration)
    line_bot_api = MessagingApi(line_api_client)
else:
    line_bot_api = None

app = Flask(__name__)
app.json.ensure_ascii = False  # 讓 Flask 回傳漂亮的繁體中文 JSON

# 設定檔案儲存目錄與資料庫路徑
AUDIO_DIR = os.path.join(os.getcwd(), 'static', 'audio')
DATABASE_PATH = os.path.join(os.getcwd(), 'prescription_vlm.db')
os.makedirs(AUDIO_DIR, exist_ok=True)

# 2. 資料庫初始化
def init_db():
    conn = sqlite3.connect(DATABASE_PATH)
    cursor = conn.cursor()

    cursor.execute('''
        CREATE TABLE IF NOT EXISTS prescriptions (
            id TEXT PRIMARY KEY,
            spoken_summary TEXT NOT NULL,
            audio_url TEXT NOT NULL,
            safety_warnings TEXT,
            created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
        )
    ''')

    cursor.execute('''
        CREATE TABLE IF NOT EXISTS medicines (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            prescription_id TEXT NOT NULL,
            name TEXT NOT NULL,
            type TEXT NOT NULL,
            frequency TEXT NOT NULL,
            dosage TEXT NOT NULL,
            timing TEXT NOT NULL,
            warning TEXT,
            FOREIGN KEY (prescription_id) REFERENCES prescriptions (id)
        )
    ''')

    conn.commit()
    conn.close()

init_db()

# 3. 傳送 LINE 關懷推播函式(含安全警語區塊)
def send_line_notification(summary, medicines_count, safety_warnings):
    if not line_bot_api or not line_user_id:
        print("⚠️ 尚未設定 LINE_CHANNEL_ACCESS_TOKEN 或 LINE_USER_ID,跳過推播。")
        return

    warning_text = ""
    if safety_warnings:
        warning_text = f"\n\n⚠️【用藥安全提醒】\n" + "\n".join([f"• {w}" for w in safety_warnings])

    push_text = f"💊【長輩用藥通知】\n剛才已完成藥袋辨識,共有 {medicines_count} 種藥品。{warning_text}\n\n白話摘要:\n{summary}"

    try:
        push_message_request = PushMessageRequest(
            to=line_user_id,
            messages=[TextMessage(text=push_text)]
        )
        line_bot_api.push_message(push_message_request)
        print("✅ LINE 關懷推播發送成功!")
    except Exception as e:
        print(f"❌ LINE 推播發送失敗: {str(e)}")

# 4. 定義包含安全檢測的 JSON Schema
prescription_schema = {
    "type": "OBJECT",
    "properties": {
        "spoken_summary": {
            "type": "STRING",
            "description": "適合唸給長輩聽的白話文總結,語氣溫柔親切。"
        },
        "safety_warnings": {
            "type": "ARRAY",
            "items": {"type": "STRING"},
            "description": "重複藥性、同類藥物過量或特殊風險提醒(如:多種止痛藥、重複抗過敏藥、需空腹服用的特殊藥物)。無風險則填空陣列。"
        },
        "medicines": {
            "type": "ARRAY",
            "items": {
                "type": "OBJECT",
                "properties": {
                    "name": {"type": "STRING", "description": "藥品中文名稱或商品名"},
                    "type": {"type": "STRING", "description": "口服或外用"},
                    "frequency": {"type": "STRING", "description": "服用頻率,如:每日二次"},
                    "dosage": {"type": "STRING", "description": "每次劑量,如:1顆、0.5顆"},
                    "timing": {"type": "STRING", "description": "吃藥時間點,如:飯後、睡前"},
                    "warning": {"type": "STRING", "description": "重要警語或注意事項,無則填空字串"}
                },
                "required": ["name", "type", "frequency", "dosage", "timing"]
            }
        }
    },
    "required": ["spoken_summary", "safety_warnings", "medicines"]
}

@app.route('/ping', methods=['GET'])
def ping():
    return jsonify({"status": "online", "service": "PrescriptionVLM Engine"}), 200

@app.route('/analyze-prescription', methods=['POST'])
def analyze_prescription():
    if 'image' not in request.files:
        return jsonify({"error": "未提供圖片檔案 (key 名稱應為 'image')"}), 400

    file = request.files['image']
    if file.filename == '':
        return jsonify({"error": "未選擇上傳的圖片"}), 400

    try:
        image = Image.open(file.stream)
        prompt = """
        你是一位專業且細心的藥師助手。請分析這張藥袋照片:
        1. 將藥品分類為口服或外用,精準提取名稱、頻率、劑量與吃藥時間。
        2. 檢查這張藥單上的藥品是否有「重複藥性」(如同時開立兩種以上消炎止痛藥、兩種以上抗過敏藥)或高風險注意事項,並填入 safety_warnings 陣列。
        3. 針對高齡長輩,撰寫一段溫柔白話的 spoken_summary,適合 TTS 語音播報。
        """

        config = types.GenerateContentConfig(
            response_mime_type="application/json",
            response_schema=prescription_schema
        )

        response = client.models.generate_content(
            model='gemini-3.6-flash',
            contents=[image, prompt],
            config=config
        )

        result_data = json.loads(response.text)

        spoken_text = result_data.get("spoken_summary", "解析完成。")
        safety_warnings = result_data.get("safety_warnings", [])

        # 若有安全警語,在白話摘要前加上防呆前綴
        if safety_warnings:
            prefix = "長輩請注意,這份藥單有特別需要留意的地方:" + ";".join(safety_warnings) + "。"
            spoken_text = f"{prefix} {spoken_text}"
            result_data['spoken_summary'] = spoken_text

        prescription_id = uuid.uuid4().hex[:8]
        filename = f"speech_{prescription_id}.mp3"
        filepath = os.path.join(AUDIO_DIR, filename)

        # 將含警告前綴的文字轉為語音檔
        tts = gTTS(text=spoken_text, lang='zh-tw')
        tts.save(filepath)

        audio_url = f"/static/audio/{filename}"
        result_data['audio_url'] = audio_url
        result_data['id'] = prescription_id

        # 寫入 SQLite
        conn = sqlite3.connect(DATABASE_PATH)
        cursor = conn.cursor()

        warnings_json = json.dumps(safety_warnings, ensure_ascii=False)
        cursor.execute(
            "INSERT INTO prescriptions (id, spoken_summary, audio_url, safety_warnings) VALUES (?, ?, ?, ?)",
            (prescription_id, spoken_text, audio_url, warnings_json)
        )

        meds = result_data.get("medicines", [])
        for med in meds:
            cursor.execute(
                """INSERT INTO medicines 
                   (prescription_id, name, type, frequency, dosage, timing, warning) 
                   VALUES (?, ?, ?, ?, ?, ?, ?)""",
                (
                    prescription_id,
                    med.get("name"),
                    med.get("type"),
                    med.get("frequency"),
                    med.get("dosage"),
                    med.get("timing"),
                    med.get("warning", "")
                )
            )

        conn.commit()
        conn.close()

        # 觸發 LINE 關懷推播
        send_line_notification(spoken_text, len(meds), safety_warnings)

        return jsonify({
            "status": "success",
            "data": result_data
        }), 200

    except Exception as e:
        return jsonify({"error": f"伺服器處理失敗: {str(e)}"}), 500

@app.route('/prescriptions', methods=['GET'])
def get_prescriptions():
    try:
        conn = sqlite3.connect(DATABASE_PATH)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        cursor.execute("SELECT * FROM prescriptions ORDER BY created_at DESC")
        prescriptions = cursor.fetchall()

        history = []
        for p in prescriptions:
            cursor.execute("SELECT name, type, frequency, dosage, timing, warning FROM medicines WHERE prescription_id = ?", (p['id'],))
            meds = [dict(m) for m in cursor.fetchall()]

            history.append({
                "id": p['id'],
                "spoken_summary": p['spoken_summary'],
                "audio_url": p['audio_url'],
                "safety_warnings": json.loads(p['safety_warnings']) if p['safety_warnings'] else [],
                "created_at": p['created_at'],
                "medicines": meds
            })

        conn.close()
        return jsonify({
            "status": "success",
            "data": history
        }), 200

    except Exception as e:
        return jsonify({"error": f"查詢失敗: {str(e)}"}), 500

@app.route('/static/audio/<filename>', methods=['GET'])
def get_audio(filename):
    return send_from_directory(AUDIO_DIR, filename)

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

三、 API 測試與成果驗證

啟動 Flask 後端服務

於 Terminal 執行,輸入(Ctrl+C 可取消執行 app.py):

python app.py

測試分析端點

開啟新的 Terminal 分頁執行 cURL 上傳藥袋:

curl -X POST http://127.0.0.1:5000/analyze-prescription \
  -F "image=@test_rx.jpg"

預期輸出結果

  • 回傳的 JSON 資料新增 safety_warnings 欄位(列出如抗過敏藥重複或抗生素需完整服用的警示)
  • 白話摘要 spoken_summary 自動組合警示前綴,並將更新後的警示內容合成為 .mp3 語音檔
  • SQLite 資料庫同步儲存 safety_warnings 欄位

四、 版本控制與提交 GitHub

測試成功後,提交 Day 09 程式碼:

git add .
git commit -m "保留雙引號 改填寫自己要記錄的標記 ex.鐵人賽第九天"
git push

五、 本日小結與明日預告

今天擴充系統的用藥安全檢測能力。PrescriptionVLM 現在能檢測重複用藥與高風險指示,為長輩與家屬把關。

明天(Day 10)進行 Docker 化與後端容器封裝。將 Python 環境、Flask 服務與 SQLite 設定封裝至 Docker 容器中,實現一鍵部署。


上一篇
Day 08|家屬不再瞎擔心!整合 LINE Messaging API 實現遠端用藥關懷推播
下一篇
Day 10|不再說「我的電腦可以跑」!用 Docker 打造可移植的 AI 藥師微服務容器
系列文
給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言